如何使用 OpenSSL 保护聊天安全?
无论是对商业通信还是个人通信来说,保护通信信道都是一件极其重要的事情,因为稍有不慎就可能导致敏感信息泄露。大多数流行的通信软件通过加密来保护对话,例如 WhatsApp, Viber 和 Telegram。目前世界上有大量的加密算法可供使用,或许正因如此,找出适合自己的通信服务的加密算法反倒成了难事。
本文旨在提供一些关于保护聊天或消息交换协议的建议,文中的示例代码使用的是 OpenSSL 库中那些流行且可靠的加密算法。本文的目标人群是那些熟悉基础的加密概念和相关术语,但又缺少实战经验的人。
文章目录:
加密算法概述
典型的安全通信应用 (WhatsApp, Telegram, Viber) 是如何工作的?
通用方案
OpenSSL 代码示例
总结
参考
加密算法概述
加密算法是用于编码信息的数学过程或公式。当信息只能被发送者和接收者获取时,就认为信息是经过加密的。即使对于云存储来讲,经过正确加密的数据应当只能被数据拥有者获取,而不能被云服务提供商获取。这种类型的加密被称作零知识加密,或者私钥加密。
零知识加密使用了以下类型的算法:
Hashes 和 PBKDF2
非对称算法 (Asymmetric algorithms)
对称算法 (Symmetric algorithms)
目前世界上有很多种安全哈希算法 (Secure Hash Algorithms, SHAs) 可供使用。古老的 SHA1 算法对于现代计算机来讲太简单了。
SHA256/SHA512 (SHA2 的变种) 是当今网络安全领域中最受欢迎的哈希函数。但是,即使是 SHA2 算法,如果只使用迭代而不添加一个被称作盐 (salt)) 的随机比特串,对于现代计算机来讲依旧很简单。PBKDF2 函数相对而言就复杂多了,因此在密码哈希中得到了广泛地使用。PBKDF2 使用一个 SHA 算法加上 salt 来计算哈希值。利用这种方法计算出的哈希值不能被用于彩虹表攻击或者一些其他依赖哈希碰撞的攻击方式。
非对称算法运行速度很慢,所以不适合用来加密大块数据,更普遍的做法是使用非对称算法加密对称算法的密钥。最著名的非对称算法是 RSA) (Rivest–Shamir–Adleman) 算法和 ECDH (Elliptic-curve Diffie–Hellman) 算法。
AES (Advanced Encryption Standard) 算法可以认为是对称加密算法中的王者了,它的身影随处可见。AES 运行速度很快,有多种语言的实现版本,现代 CPU 硬件甚至内置地支持它。
证书 (Certificates) 是保护服务端和客户端数据交换的另一个重要组成部分。如果证书无效,就不能确保连接的安全性。证书本质上是一种非对称算法系统,用来确认服务端。
典型的安全通信应用 (WhatsApp, Telegram, Viber) 是如何工作的?
通用方案
为了给通信双方提供一条安全的连接,必须考虑清楚通信软件的每一个环节,包括从注册到发送消息整个过程。交换消息需要经过以下四个步骤:
注册 (图二)
登陆 (图三和图四)
添加用户到联系人列表 (图五)
交换消息 (图六)
对于注册过程,应用程序应该首先向服务器请求服务器的密钥,以此确保可以安全地处理用户的数据。
图一 注册之前先请求服务器的密钥
图一中展示了应用程序请求了服务器的何种密钥,这些密钥会在应用程序和服务器之间的进行注册、登陆和其他一些交互操作时发挥作用,将用来验证和加密与服务器交换的数据。请求过后,用户的所有数据都必须用服务器的密钥加密。
服务器生成了以下密钥:
SPbK——服务器的公有密钥 (RSA/ECDH)
SPrK——服务器的私有密钥 (RSA/ECDH)
SPbSK——服务器的公有签名密钥 (RSA/ECDSA)
SPrSK——服务器的私有签名密钥 (RSA/ECDSA)
SPbK 用来加密会话密钥或者一些只应被服务器获取的小规模安全信息。
在注册过程中,应用程序会生成所有的必需密钥,然后将它们加密后发送给服务器。将私有密钥发送给服务器是为了应对用户从另一个设备登陆的情况 (详见下文)。私有密钥会使用一个只存在于用户设备的主密钥 (Master Key, MK) 加密,这样一来服务器便不可能对其解密。
上述过程如图二所示。
图二 注册
经过这一步后,有了用户的密钥,所有其它数据的交换变得更加安全了。服务器可以使用 UPbK 来加密会话密钥。
图三 会话密钥和服务器验证
(译者注:我认为图三中的 Check sign with UPrSk 应该改为 Check sign with SPbSK 因为这里的数据是服务器用其私有签名密钥进行签名的,对应地,应该用服务器的公有签名密钥检查签名)
一旦注册过程成功完成,用户就拥有了以下密钥:
认证密钥 (AK) 用来做认证,客户端必须将其发送给服务端,服务器就是用这个密钥来鉴别用户的。AK 是用 PBKDF2 函数生成的,而且 AK 和 MK 必须不同。这里举个生成 AK 的例子:AK=PBKDF2(password, user’s email as salt, 10000)
主密钥 (MK) 用来加密和解密用户的密钥包,该密钥不能被发送到服务器或者其他任何地方,必须保存在本地。这里举个生成 MK 的例子:MK=PBKDF3(password, username + “@” + domain + username as salt, 20000)
用户的私有密钥 (UPrK)
用户的私有签名密钥,用来签名 (UPrSK)
用户的公有密钥 (UPbK)
用户的公有签名密钥,用来检查签名 (UPbSK)
服务器的公有密钥 (SPbk)
服务器的公有签名密钥,用来检查签名 (SPbSK)
会话密钥,用来加密所有将发送给服务器的数据 (SK)
会话密钥用来加速服务器和设备之间的数据加解密。非对称算法由于速度慢所以不适合这种场景。
现在我们假设用户想从另一个新设备登陆,这时候工作流程是怎样的呢?
图四 登陆
从图中可以看出,用户的密码用于生成 AK 和 MK。如果用户从新设备登陆,应用程序便会向服务器请求服务器的密钥,然后用其来加密 AK,见图一。截至目前,用户已经拥有了所有必需的密钥,接下来就可以和其他用户聊天了。
图五 用户之间的密钥交换
经过图五中的密钥交换过程后,用户 1 就拥有了用户 2 的公有密钥,同样地,用户 2 也拥有了用户 1 的公有密钥,这些密钥会用来保护聊天安全。使用这些密钥加密的数据只能被这两个用户获取(因为服务器没有对应的私钥,无法解密数据)。
这两个用户现在可以开始互相发送消息了。
图六 聊天
同样的逻辑也适用于用户 2,使用 SK 加密消息,用自己的私有签名密钥 U2PrSk 签名,然后发送给用户 1。如果有发送大块二进制数据的需求的话,这块数据中也可以包含它自己的数据密钥,这个数据密钥可以使用用户的公钥进行加密。
OpenSSL 代码示例
如果你想做出一款安全的通信软件,可以使用 OpenSSL 来实施保护措施。OpenSSL 库中的函数有很好的说明文档,很容易找到示例和说明。
这里举个例子。下面列出了上面所讲述的加密过程中所需要的一些操作:
准备主密钥
生成用户的公有密钥和私有密钥
使用 AES 进行加解密
加密密钥对
使用 RSA 对消息进行签名
检验签名信息
下面的所有函数都使用了 OpenSSL API。
下面一段代码展示了如何利用用户密码和邮件生成主密钥。
void GeneratePbkdf2Sha256Hash(const std::string& username,
const std::string& domain,
const std::string& password,
/*OUT*/ Bytes_vt& passwordHash)
{
const int kPasswordHashIterationNumber = 10000;
const int kPasswordHashSize = 32;
std::string salt(userName + "@" + domain + userName);
passwordHash.resize(kPasswordHashSize);
int result = PKCS5_PBKDF2_HMAC(password.c_str(),
static_cast<int>(password.size()),
reinterpret_cast<const unsigned char*>(salt.data()),
static_cast<int>(salt.size()),
iterationsNumber,
EVP_sha256(),
static_cast<int>(passwordHash.size()),
reinterpret_cast<unsigned char*>(passwordHash.data()));
if (1 != result)
{
int errorCode = static_cast<int>(ERR_get_error());
throw OpensslException(errorCode, ERR_error_string(errorCode, NULL));
}
}
下面一段代码展示了如何生成 RSA 密钥。这些密钥作为用户的公有和私有密钥,将被用于加密和签名。其中 RSA_generate_key 是真正产生密钥的函数。
struct RsaKeyPair
{
Bytes_vt n; //public modulus
Bytes_vt e; //public exponent
Bytes_vt d; //private exponent
Bytes_vt p; //first secret prime factor
Bytes_vt q; //second secret prime factor
Bytes_vt dmp1; //dmp1 in RSA struct
Bytes_vt dmq1; //dmq1 in RSA struct
Bytes_vt iqmp; //iqmp in RSA struct
};
void BignumToBinary(BIGNUM* bignum, Bytes_vt& binary)
{
binary.resize(BN_num_bytes(bignum));
BN_bn2bin(bignum, reinterpret_cast<unsigned char*> (&binary.at(0)));
}
void GenerateRsaKeyPair(RsaKeyPair& rsaKeyPair)
{
std::shared_ptr<RSA> rsaWrapper(
RSA_generate_key(kRsaKeySizeBits, kRsaPublicExponent, NULL, NULL),
RSA_free);
BignumToBinary(rsaWrapper->n, rsaKeyPair.n);
BignumToBinary(rsaWrapper->e, rsaKeyPair.e);
BignumToBinary(rsaWrapper->d, rsaKeyPair.d);
BignumToBinary(rsaWrapper->p, rsaKeyPair.p);
BignumToBinary(rsaWrapper->q, rsaKeyPair.q);
BignumToBinary(rsaWrapper->dmp1, rsaKeyPair.dmp1);
BignumToBinary(rsaWrapper->dmq1, rsaKeyPair.dmq1);
BignumToBinary(rsaWrapper->iqmp, rsaKeyPair.iqmp);
}
下面一段代码展示了一个隐藏了 OpenSSL AES 加密过程,然后提供了一个简单的接口使用它的类。
static const int kAesIvSize = AES_BLOCK_SIZE; // AES_BLOCK_SIZE is from openssl/aes.h and equals 16
AesGcmCryptor::AesGcmCryptor(const Byte* aesKey, size_t keySizeInBytes, const Bytes_vt& initializationVector)
: m_aesKey(aesKey, aesKey + keySizeInBytes)
, m_iv(initializationVector) {}
void aesGcmEncrypt(const Byte* plainBytes, size_t plainBytesSize, Bytes_vt& cipherBytes) {
cipherBytes.resize(plainBytesSize);
CipherContextGuard ctxGuard(EVP_CIPHER_CTX_new());
EVP_EncryptInit_ex(ctxGuard.getContext(),
EVP_aes_256_gcm(),
NULL,
NULL,
NULL);
/* Set IV length if default 12 bytes (96 bits) is not appropriate */
EVP_CIPHER_CTX_ctrl(ctxGuard.getContext(),
EVP_CTRL_GCM_SET_IVLEN,
static_cast<int>(m_iv.size()),
NULL);
/* Initialise key and IV */
EVP_EncryptInit_ex(ctxGuard.getContext(), NULL, NULL,
reinterpret_cast<unsigned char*>(&m_aesKey.at(0)),
reinterpret_cast<unsigned char*>(&m_iv.at(0)));
/* Provide the message to be encrypted, and obtain the encrypted output.
* EVP_EncryptUpdate can be called multiple times if necessary */
int len;
EVP_EncryptUpdate(ctxGuard.getContext(),
reinterpret_cast<unsigned char*>(&cipherBytes.at(0)),
&len,
reinterpret_cast<const unsigned char*>(plainBytes),
static_cast<int>(plainBytesSize));
/* Finalize the encryption. Normally ciphertext bytes may be written at
* this stage, but this does not occur in GCM mode */
EVP_EncryptFinal_ex(ctxGuard.getContext(),
reinterpret_cast<unsigned char*>(&cipherBytes.at(0)) + len,
&len);
/* Get the tag */
Bytes_vt tagBytes(kAesGcmTagSize);
EVP_CIPHER_CTX_ctrl(ctxGuard.getContext(),
EVP_CTRL_GCM_GET_TAG,
kAesGcmTagSize,
reinterpret_cast<unsigned char*>(&tagBytes.at(0)));
cipherBytes.insert(cipherBytes.end(), tagBytes.begin(), tagBytes.end());
}
下面一段代码展示了一个 AES 解密过程。
void aesGcmDecrypt(const Byte* cipherBytes, size_t cipherBytesSize, Bytes_vt& plainBytes) {
size_t plainBytesSize = cipherBytesSize - kAesGcmTagSize;
plainBytes.resize(plainBytesSize);
CipherContextGuard ctxGuard(EVP_CIPHER_CTX_new());
EVP_DecryptInit_ex(ctxGuard.getContext(), EVP_aes_256_gcm(), 0, 0, 0);
/* Set IV length if default 12 bytes (96 bits) is not appropriate */
EVP_CIPHER_CTX_ctrl(ctxGuard.getContext(),
EVP_CTRL_GCM_SET_IVLEN,
static_cast<int>(m_iv.size()),
NULL);
/* Initialize key and IV */
EVP_DecryptInit_ex(ctxGuard.getContext(),
NULL,
NULL,
reinterpret_cast<unsigned char*>(&m_aesKey.at(0)),
reinterpret_cast<unsigned char*>(&m_iv.at(0)));
/* Provide the message to be decrypted, and obtain the plaintext output.
* EVP_DecryptUpdate can be called multiple times if necessary */
int len;
EVP_DecryptUpdate(ctxGuard.getContext(),
reinterpret_cast<unsigned char*>(plainBytes.data()),
&len,
reinterpret_cast<const unsigned char*>(cipherBytes),
static_cast<int>(plainBytesSize));
int plaintext_len = len;
/* Set expected tag value.*/
char tag[kAesGcmTagSize];
memcpy(tag, cipherBytes + cipherBytesSize - kAesGcmTagSize, kAesGcmTagSize);
EVP_CIPHER_CTX_ctrl(ctxGuard.getContext(), EVP_CTRL_GCM_SET_TAG, kAesGcmTagSize, tag);
/* Finalize the decryption. A positive return value indicates success,
* anything else is a failure - the plaintext is not trustworthy.
*/
EVP_DecryptFinal_ex(ctxGuard.getContext(),
reinterpret_cast<unsigned char*>(plainBytes.data()) + len,
&len);
plaintext_len += len;
plainBytes.resize(plaintext_len);
}
下面一段代码展示了加密密钥对,其中的密钥对是用上面的函数生成的。
Bytes_vt EncryptBytesUsingAesGcm(const Byte* plainBytes,
size_t plainBytesSize,
const Bytes_vt& aesKey,
const Bytes_vt& iv)
{
Bytes_vt cipherBytes;
crypto::AesGcmCryptor cryptor(aesKey.data(), aesKey.size(), iv);
cryptor.encrypt(plainBytes, plainBytesSize, cipherBytes);
return cipherBytes;
}
Bytes_vt encryptPrivateKeys(const RsaKeyPair& encryptDecryptKeyPair,
const RsaKeyPair& signVerifyKeyPair,
const Bytes_vt& encryptionKey)
{
Bytes_vt pdkIv(kAesIvSize);
GenerateRandomBytes(pdkIv.data(), pdkIv.size());
Bytes_vt pskIv(kAesIvSize);
GenerateRandomBytes(pskIv.data(), pskIv.size());
std::string encryptDecryptString = KeyToString(encryptDecryptKeyPair);
std::string signVerifyString = KeyToString(signVerifyKeyPair);
Bytes_vt encryptedPdk = EncryptBytesUsingAesGcm(encryptDecryptString.c_str(),
encryptDecryptString.size(),
encryptionKey,
pdkIv);
Bytes_vt encrytedPsk = EncryptBytesUsingAesGcm(signVerifyString.c_str(),
signVerifyString.size(),
encryptionKey,
pskIv);
uint16_t encryptedPdkLen = static_cast<uint16_t>(encryptedPdk.size());
Bytes_vt privateKeysBlob(sizeof(encryptedPdkLen) + pdkIv.size() + pskIv.size() +
encryptedPdk.size() + encrytedPsk.size());
privateKeysBlob[0] = static_cast<Byte>(encryptedPdkLen % 0x100);
privateKeysBlob[1] = static_cast<Byte>(encryptedPdkLen / 0x100);
auto it = privateKeysBlob.begin() + sizeof(encryptedPdkLen);
it = std::copy(pdkIv.begin(), pdkIv.end(), it);
it = std::copy(pskIv.begin(), pskIv.end(), it);
it = std::copy(encryptedPdk.begin(), encryptedPdk.end(), it);
it = std::copy(encrytedPsk.begin(), encrytedPsk.end(), it);
return privateKeysBlob;
}
下面一段代码展示了如何使用 RSA 对将要发送的数据进行签名。
void rsaSign(
const Byte* source,
size_t sourceSize,
Bytes_vt& signature,
RSA* rsa)
{
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256_CTX sha256Context = {0};
SHA256_Init(&sha256Context);
SHA256_Update(&sha256Context, source, sourceSize);
SHA256_Final(hash, &sha256Context);
signature.resize(RSA_size(m_rsa.get()));
unsigned int signatureResultSize = 0;
int result = RSA_sign(NID_sha256,
hash,
sizeof(hash),
reinterpret_cast<unsigned char*> (&signature.at(0)),
&signatureResultSize,
rsa);
if (1 != result)
{
_THROW_WITH_DEFAULT_INFO;
}
if (signatureResultSize != signature.size())
{
_THROW(cryptoErrorSigning);
}
}
下面一段代码展示了接收数据的一方是如何进行检验的。
bool rsaVerify(
const Byte* source,
size_t sourceSize,
const Byte* signature,
size_t signatureSize,
RSA* rsa)
{
unsigned char hash[SHA256_DIGEST_LENGTH];
SHA256_CTX sha256Context = {0};
SHA256_Init(&sha256Context);
SHA256_Update(&sha256Context, source, sourceSize);
SHA256_Final(hash, &sha256Context);
int result = RSA_verify(NID_sha256,
hash,
sizeof(hash),
reinterpret_cast<const unsigned char*> (signature),
static_cast<int> (signatureSize),
rsa);
return (1 == result);
}
总结
本文我们讲述了两名用户进行经过加密的即时消息交流背后的一些安全原则。OpenSSL 库在大多数情况下是一个很不错的选择。上文中所描述的加密方案在大多数情况下的效率表现是相当不错的,而且可以根据软件的需求方便地对其进行修改。
通信安全是一个很大的领域,要想覆盖所有的情况要耗费的时间或许是相当惊人的。我们在本文中仅仅讨论了如何在用户之间安全地传递消息,但还有很多东西是值得去思考的:
服务器端安全和如何在数据库中安全地存储密码(当然不能以明文存储了:)
客户端对于安全数据的缓存
生成 salt
如何选择加密算法
参考
https://wiki.openssl.org
https://en.wikipedia.org/wiki/Elliptic_Curve_Digital_Signature_Algorithm
https://en.wikipedia.org/wiki/Elliptic_curve_Diffie%E2%80%93Hellman
https://en.wikipedia.org/wiki/Advanced_Encryption_Standard
https://www.boxcryptor.com/en/technical-overview/
https://www.sitepoint.com/risks-challenges-password-hashing/
https://www.ssl2buy.com/wiki/difference-between-hashing-and-encryption
https://www.ssl2buy.com/wiki/symmetric-vs-asymmetric-encryption-what-are-differences
https://www.owasp.org
本文由看雪翻译小组 hesir 编译,来源apriorit@Dev
转载请注明来自看雪社区
往期热门阅读:
点击阅读原文/read,
更多干货等着你~
扫描二维码关注我们,更多干货等你来拿!